本日程式碼:repo tag day-22
先複習一下用詞(我其實寫到後面有時候都混淆了):
好,現在一份 300 行的撥款檔案,第 146 行跟第 145 行寫的是同一筆 intent。這一行走到鏈上會發生什麼事?在 EVM 上,settleBatch 是一項失敗整個 batch 回滾,偏偏同一把 ref 被取用第二次就是一種失敗。整批一起退回來,跟它無關的幾百筆也停在原地。在 Solana 上更糟:昨天的 run 只認 payer 簽過的 root,兩行都在樹上就是兩片各自合法的葉子,同一筆 intent 會照付兩次。今天要決定的是這一行該在哪一關被攔下來,以及攔下來之後,同一份檔案裡沒問題的那幾百行怎麼辦。
我們今天做的東西還是在鏈下,而且比組成 batch 更前面一步。它叫 intake,把一份 CSV 讀成一份可以送出去的名單,順便交代它剔掉了哪幾行、為什麼剔。切成 batch 那一段已經有人管了,今天要補的是「這份名單憑什麼可以送」。而在 Solana 那條路上它還多背一件事:驗完收下來的名單,就是 payer 要簽進 root 的那份名單,行的順序就是葉子的順序,init_run 之前的最後一關就是這裡。
動筆之前先看別人怎麼處理 partial failure:
batchItemFailures 清單做完之後 repo 中 internal/intake 的 Example_readAPayoutFile 會拿同一份 300 行的檔案讀兩次,輸出長這樣:
intake solana 300 rows 0 accepted 4 rejected
intake: rejected rows and the policy does not skip them: 4 of 300 rows
reject line 41 amount "100.00" is not a whole number of minor units
reject line 89 merchant "" is not a merchant
reject line 146 duplicate pi_0144 already appears on line 145
reject line 213 fields the row has 2 fields, want 3
intake solana 300 rows 296 accepted 4 rejected
plan solana 296 payouts 37 batches 0 new accounts rent 0 lamports
trace batch #11 8 items csv lines 83-88, 90-91
Policy,所以第一行那個 0 accepted 講的是整份退回這件事。Accepted 被刻意清空了,三百行裡面其實有 296 行合格bulk 照「一批 8 筆、切在 8 的倍數邊界」算出來的,而 8 又是照 Solana 那 1,232 bytes 加上整批共用的證明算的,所以這一行跟那個 8 一樣有保存期限83-88, 90-91 中間缺了 89,因為第 89 行被剔掉了。有這種缺口在,batch 的第幾項對到檔案第幾行就算不出來partial failure 有三種,而它們能收拾的範圍差很多:
表頭那一關刻意不做任何容錯。表頭一旦對不上,接下來每一欄的意義都是猜的。猜錯的下場是把 intent id 當成 merchant 付出去。所以表頭不對就是整份退回,一行都不讀。(試算表存出來的 CSV 開頭常常帶一個 BOM,那不是表頭寫錯,Read 會先把它修掉再比對。)
一行不合格有五種理由,分成三種形狀:整行的欄位數不對(fields)、某一欄的內容自己不合格(intent_id、merchant、amount)、以及跟別的行有關係(duplicate)。duplicate 指的是同一個 intent id 在這份檔案裡出現第二次。它才是這一關存在的理由,因為鏈上攔不到它。EVM 的合約認的是同一把 ref 不能被取用兩次。同一筆 intent 寫成兩行不同的金額會算出兩把不同的 ref,兩行都放行;兩行一模一樣倒是攔得住,收場是整個 batch 回滾。Solana 的 run 連這一半都沒有:payer 把 root 簽下去之後,程式只認「這片葉子在不在樹上、這片付過沒有」,兩行一模一樣就是兩片各自合法的葉子,照付兩次。名單這一關在那條鏈上沒有備援。
五個檢查照順序跑,其中一步的位置是有陷阱的。duplicate 靠一張「看過的 intent id」表比對,那個 id 該在什麼時候記進表?直覺的答案是等一整行都合格才記,畢竟壞掉的行本來就不該算數。但那樣會漏:第 89 行的 merchant 空著、整行被剔掉,pi_0088 沒進表,之後同一個 id 再出現一次就會直接過關,那一筆也就照樣付出去了。報告交出去,operator 照著把第 89 行的 merchant 補好重送,同一筆 intent 就付了第二次。所以 id 是在 intent_id 那一關過了就記,不等後面兩欄。這一行自己合不合格,跟「這個 id 在這份檔案裡已經出現過」是兩件事。
金額那一欄只收最小單位的正整數,100.00 一律退回。這是三欄裡面最容易被說成「順手換算一下就好」的一欄。要換算得先知道這顆 token 有幾位小數,而那不是這一行講得出來的事。
一行壞掉、其餘的行怎麼辦,這一題沒有技術上的正確答案。所以我拿產品的問法問一次:這三條路各自出事之後,誰要接那通電話?
100.00 猜成 18 位就是多付一兆倍沒有客訴才是第三條最麻煩的地方,所以我們完全不做當場修正。前兩條之間我選整份退回當預設值。撥款檔案多半是另一個系統產生的,某一行不合格通常是產生它的那一段壞了,很少是那一個 merchant 本身特別。這種時候先付掉另外 296 筆,等於把一個還沒查清楚的錯誤變成一筆已經送出去的錢。
這個推論靠的是「檔案由系統產生」這個前提。如果你的撥款檔案是每個 merchant 自己填一列寄回來的,一行壞掉就真的只是那一行壞掉,預設值應該反過來。
所以 Skip 這個開關要 operator 看過報告才打得開,而且打開之後還有一個上限:
if n := len(run.Rejected); n > 0 {
switch {
case !p.Skip:
run.Accepted, run.Lines = nil, nil
return run, fmt.Errorf("%w: %d of %d rows", ErrRejected, n, run.Rows)
case p.MaxRejects > 0 && n > p.MaxRejects:
run.Accepted, run.Lines = nil, nil
return run, fmt.Errorf("%w: %d rejected, the limit is %d", ErrTooManyRejects, n, p.MaxRejects)
}
}
兩條路都把 Accepted 清成空的。這一行看起來多餘,實際上是整個 package 唯一的保險:Read 回的是一份 Run 加一個 error,但呼叫端漏看 error 這件事遲早會發生一次,發生的時候最糟只會送出零筆。
MaxRejects 用絕對數字不用比例。一份三行的檔案壞掉一行是三成三,一份三千行的檔案壞掉一行是萬分之三,但要人去看的工作量一樣多。用比例的話大檔案的容忍度會跟著長大,而大檔案正是最不該放寬的那一種。
上鏈之後就沒有「哪幾筆成功」這種問題了:一批全成或全敗,所以鏈上唯一會出現的 partial failure,單位就是一批。麻煩的是這個單位在檔案上沒有名字。
operator 手上有的是那份 CSV,而 Plan 只有我們自己看得到,所以 Trace 要回答的是「第 11 批對到第幾行」。這個對應算不出來:中間有四行被剔掉了,第幾批第幾項跟第幾行之間差多少,取決於前面剔了幾行。Run 因此在收下每一筆付款的同時記著它來自第幾行,Trace 只是把兩份順序疊在一起數過去。
能這樣數,是因為 bulk 的 Pack 照名單原本的順序切、不重排也不丟項。不重排本來只是為了讓報告讀得懂,昨天它多背了「名單的順序就是葉子的順序」,今天它又變成 Trace 算得對的前提。哪天有人把 Pack 換成裝箱演算法,Trace 不會失敗,它會安靜地給出一串看起來完全正常的錯誤行號。所以它自己先檢查兩件事:這份計畫的筆數跟這份 Run 收下的一不一樣,以及每一批的每一項對到的 merchant 是不是同一個。前者擋數量,後者擋順序,難發現的一直是順序。
至於那八行回來之後怎麼辦,分類早就做好了:retryable 就原本那個 batch 重送,poison 就把該剔的那幾行剔掉、重新切一次 batch 再送。重新切會讓後面每一批的邊界整個位移,舊的 Plan 跟著作廢。在 Solana 上更是整棵樹都換了一棵,root 要重簽、run 要重開。Trace 認的是「這份 Plan 配這份 Run」這一組,所以重切完要連 Run 一起換一份新的,不能拿新的 batch 編號去查舊的行號。
到目前為止,EVM 與 Solana 的差別都還靠一句「那條鏈的規則不一樣」(的爛藉口?)擋在各自的常數裡。明天來看看這兩條鏈的差異該用什麼形狀的介面收起來。
明天見。